在現代框架的單頁應用 (Single Page Application, SPA) 中,其中一個技術是在不重整頁面的情況下,讓使用者感受到順暢的頁面過渡效果,這個技術的背後就是 History API,今天要跟大家分享 History API 究竟幫我們做了哪些事情?他在 SPA 中又扮演了什麼角色?
History API 可以讓開發者操作瀏覽器的歷史記錄。我們可以在不重整頁面的情況下更新 URL,還能讓瀏覽器的上一頁、下一頁正常運作。
History API 的特性包括:
這些特性,其實就跟傳統的多頁面網站一樣,也就是說,透過使用 History API,我們可以建立像傳統多頁面網站的單頁應用,同時又保持單頁應用的速度和流暢性。
History API 提供了幾個方法,讓開發者可以操作歷史紀錄
這個方法讓我們向瀏覽歷史中增加一個新的狀態。它的語法如下:
history.pushState(state, title, url)
state: 一個 JavaScript 物件,包含與新歷史記錄相關的數據。title:新頁面的標題。url:新頁面的網址。這個方法與 pushState() 類似,但他會替換當前的歷史記錄,而不是增加新的紀錄。
history.replaceState(state, title, url)
他會在使用者使用瀏覽器上一頁、下一頁功能時觸發,我們可以使用它來處理狀態的變化。
window.onpopstate = function(event) {
  // 處理狀態變化
  console.log(event.state);
}
我放了三個連結,以及一個 id=content 的元素顯示對應頁面的內容
<nav>
  <a href="home">首頁</a>
  <a href="about">關於</a>
  <a href="contact">聯絡我們</a>
</nav>
<div id="content"></div>
我們希望點不同連結,會顯示對應的內容,將這個動作包成一個函式 updateContent(),根據 page 參數顯示資料
function updateContent(page) {
  document.getElementById('content').innerHTML = `這是 ${page} 頁面的內容`;
}
呼叫 updateContent() 的時機點:
load()
window.onpopstate
navigateTo()
function navigateTo(page) {
  const state = { page: page };
  const title = page;
  const url = `./${page}`;
  history.pushState(state, title, url);
  updateContent(page);
}
window.onpopstate = function (event) {
  if (event.state) {
    updateContent(event.state.page);
  }
};
window.addEventListener('load', function () {
  const initialPage = window.location.pathname || 'home';
  updateContent(initialPage);
});
用 querySelectorAll 取得所有 nav a 元素,並呼叫 navigateTo() 函式。而要實現不重整頁面的效果,記得用 e.preventDefault() 阻擋頁面預設的行為
document.querySelectorAll('nav a').forEach(link => {
  link.addEventListener('click', function (e) {
    // 阻擋頁面預設行為,防止頁面跳轉
    e.preventDefault();
    const page = this.getAttribute('href');
    navigateTo(page);
  });
});
navigateTo() 有用到 history.pushState(),我們在點擊連結時,會增加歷史瀏覽的紀錄,能有效切換上下頁。
function navigateTo(page) {
  const state = { page: page };
  const title = page;
  const url = `./${page}`;
  // 新增一筆歷史紀錄
  history.pushState(state, title, url);
  updateContent(page);
}

讓我們再更深入的做一些好玩的應用!
跟傳統的多頁面網站不同,SPA 的網址處理機制需要特別的設計。在傳統網站中,每個 URL 都對應一個實際頁面。然而,在 SPA 中,所有的內容都在一個頁面中動態載入,因此我們要手動處理 URL 的變化。
當使用者在瀏覽器中輸入一個 URL 時,我們需要確保應用能夠正確地載入對應的內容。這就是深度連結的核心概念。
前端需要監聽 load()事件,並根據 URL 載入對應的內容
function handleInitialLoad() {
  const path = window.location.pathname.substr(1);
  if (path) {
    navigateTo(path);
  } else {
    navigateTo('home');
  }
}
window.addEventListener('load', handleInitialLoad);
後端則需要協助修改伺服器設定,就跟大多數的 SPA 一樣,我們只會有一個入口頁面,所以要將路由指向同一份 HTML 檔案。以 Nginx 為例子,設定可能如下:
location / {
  try_files $uri $uri/ /index.html;
}
使用者輸入什麼 URL,伺服器都會根據設定返回 index.html,再從前端處理路由。
這邊以我的部落格文章為例子,實作不用重新載入就能顯示文章內容。

一樣先處理 HTML 元素,連結是我部落格文章的實際 URL,我去爬文章內容,再將它渲染到 <div id="content"></div>
<nav>
  <a href="cdn-tailwindcss-vscode-enable-tailwindcss-intellisense/">文章一</a>
  <a href="introduction-singleton-design-pattern/">文章二</a>
  <a href="quill-react-ant-design-and-upload-image/">文章三</a>
</nav>
<div id="content"></div>
我修改了 navigateTo() 函式,用 async/await 語法處理非同步操作,再用 fetch() 取得我的文章內容
async function navigateTo(page) {
  showLoading();
  try {
    const res = await fetch(`https://muki.tw/${page}`);
    if (!res.ok) {
      throw new Error(`HTTP error! status: ${res.status}`);
    }
    const htmlContent = await res.text();
    const extractedContent = extractContent(htmlContent);
    history.pushState({ page, content: extractedContent }, page, `/${page}`);
    updateContent(extractedContent);
  } catch (error) {
    console.error('Fetch error:', error);
    updateContent('載入失敗,請稍後再試。');
  } finally {
    hideLoading();
  }
}
res.text() 會顯示從整份 HTML 文件,但我只想要文章內容,所以用 extractContent()  來處理最後要顯示的 HTML
function extractContent(htmlString) {
  const parser = new DOMParser();
  const doc = parser.parseFromString(htmlString, 'text/html');
  // 使用屬性選擇器來選擇目標 div
  const targetDiv = doc.querySelector('div[class*="min-h-[calc(100vh-70px)]"]');
  if (targetDiv) {
    return targetDiv.innerHTML;
  } else {
    // 如果找不到指定的 div,嘗試獲取 body 內容
    const bodyContent = doc.body.innerHTML;
    return bodyContent || '找不到指定的內容';
  }
}
載入文章時,可以適時地增添 loading 效果,讓體驗更順暢
function showLoading() {
  const loadingElement = document.createElement('div');
  loadingElement.className = 'loading';
  contentElement.appendChild(loadingElement);
}
function hideLoading() {
  const loadingElement = document.querySelector('.loading');
  if (loadingElement) {
    loadingElement.remove();
  }
}
以上雖是一個簡易的實作,但我們整合了 History API、非同步以及 loading 效果處理,讓大家了解 History API 可以有怎樣的運用,也許未來在使用現代框架時,可以更瞭解路由端的設計原理與實作方式。
我們能使用 History API 實現頁面切換,同時支援深度連結,還能保持瀏覽器歷史的完整性。但在使用上,也需要注意一些潛在問題:
以上就是 History API 的介紹,有任何問題都歡迎留言討論唷。